package edu.northwestern.cbits.purple_robot_manager.triggers; import java.io.IOException; import java.io.StringReader; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; import net.fortuna.ical4j.data.CalendarBuilder; import net.fortuna.ical4j.data.ParserException; import net.fortuna.ical4j.model.Calendar; import net.fortuna.ical4j.model.Component; import net.fortuna.ical4j.model.DateRange; import net.fortuna.ical4j.model.DateTime; import net.fortuna.ical4j.model.Period; import net.fortuna.ical4j.model.PeriodList; import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; import android.os.Bundle; import android.preference.Preference; import android.preference.PreferenceManager; import android.preference.PreferenceScreen; import org.apache.commons.lang3.builder.HashCodeBuilder; import edu.emory.mathcs.backport.java.util.Collections; import edu.northwestern.cbits.purple_robot_manager.ManagerService; import edu.northwestern.cbits.purple_robot_manager.R; import edu.northwestern.cbits.purple_robot_manager.logging.LogManager; @SuppressLint( { "SimpleDateFormat", "TrulyRandom" }) public class DateTrigger extends Trigger { public static final String TYPE_NAME = "datetime"; public static final String DATETIME_START = "datetime_start"; public static final String DATETIME_END = "datetime_end"; public static final String DATETIME_REPEATS = "datetime_repeat"; public static final String DATETIME_RANDOM = "datetime_random"; private static final String RANDOM = "random"; private static final String START = "start"; private static final String END = "end"; private static final String ORIGINAL_END = "original_end"; private static final String CALENDAR_STRING = "calendar_rule"; private static final String ORIGINAL_START = "original_start"; private static final String REPEATS = "repeats"; private static final String FIRE_ON_BOOT = "fire_on_boot"; private static SecureRandom random = null; private boolean _random = false; private String _start = null; private String _end = null; private String _originalStart = null; private String _originalEnd = null; private String _repeats = null; private boolean _fireOnBoot = false; private Calendar _calendar = null; private String _icalString = null; private long _lastFireCalcDate = 0; private final List<Date> _upcomingFireDates = new ArrayList<>(); private static List<Runnable> pendingRefreshes = new ArrayList<>(); private static boolean isRefreshing = false; public String getCalendarString() { return this._icalString; } private abstract class RefreshRunnable implements Runnable { private String _identifier = null; public RefreshRunnable(String identifier) { this._identifier = identifier; } @Override public int hashCode() { return new HashCodeBuilder(17, 31).append(this._identifier).toHashCode(); } @Override public boolean equals(Object obj) { if (obj == null) return false; return obj.hashCode() == this.hashCode(); } } static { try { DateTrigger.random = SecureRandom.getInstance("SHA1PRNG"); } catch (NoSuchAlgorithmException e) { LogManager.getInstance(null).logException(e); } } @Override public void refresh(final Context context) { final DateTrigger me = this; RefreshRunnable r = new RefreshRunnable(this.identifier()) { @Override public void run() { me.refreshTrigger(context); } }; if (DateTrigger.pendingRefreshes.contains(r) == false) DateTrigger.pendingRefreshes.add(r); if (DateTrigger.isRefreshing == false) { DateTrigger.isRefreshing = true; Runnable s = new Runnable() { @Override public void run() { if (DateTrigger.pendingRefreshes.size() > 0) { Runnable r = DateTrigger.pendingRefreshes.remove(0); if (r != null) r.run(); this.run(); } else DateTrigger.isRefreshing = false; } }; Thread t = new Thread(s, "Trigger Refresh"); t.start(); t.setName("Trigger Refresh"); } } public void refreshTrigger(final Context context) { long now = System.currentTimeMillis(); if (now - this._lastFireCalcDate > (3600 * 1000)) { this._lastFireCalcDate = now; this.refreshCalendar(context); if (this._calendar == null) return; ArrayList<Date> upcoming = new ArrayList<>(); long current = now; long maxCount = 64; long hour = 1000 * 60 * 60; Date currentDate = new Date(now); while (current < now + (hour * 48) && upcoming.size() < maxCount) { DateTime from = new DateTime(new Date(current)); DateTime to = new DateTime(new Date(current + hour)); try { for (Object o : this._calendar.getComponents("VEVENT")) { Component c = (Component) o; Period period = new Period(from, to); PeriodList l = c.calculateRecurrenceSet(period); for (Object po : l) { if (po instanceof Period) { Period p = (Period) po; Date start = p.getRangeStart(); if (start.after(currentDate)) { if (upcoming.contains(start) == false) upcoming.add(start); } } } } } catch (NullPointerException e) { } catch (IllegalArgumentException e) { LogManager.getInstance(context).logException(e); } current += hour; } try { synchronized (this._upcomingFireDates) { this._upcomingFireDates.clear(); this._upcomingFireDates.addAll(upcoming); } } catch (NullPointerException e) { LogManager.getInstance(context).logException(e); } } } @Override public void reset(Context context) { super.reset(context); String key = "last_fired_" + this.identifier(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); Editor edit = prefs.edit(); edit.remove(key); edit.commit(); } public void merge(Context context, Trigger trigger) { if (trigger instanceof DateTrigger) { super.merge(trigger); DateTrigger dateTrigger = (DateTrigger) trigger; this._icalString = dateTrigger._icalString; this._random = dateTrigger._random; this.refreshCalendar(context); } } private void refreshCalendar(Context context) { if (this._icalString == null) return; try { StringReader sin = new StringReader(this.adjustCalendar(context, this._icalString)); CalendarBuilder builder = new CalendarBuilder(); this._calendar = builder.build(sin); } catch (NullPointerException | ParserException | IOException e) { LogManager.getInstance(context).logException(e); } } private String adjustCalendar(Context context, String original) { if (original.contains("FREQ=MINUTELY") && original.contains("COUNT=") == false) { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss"); StringBuilder newCalendar = new StringBuilder(); long day = (24 * 60 * 60 * 1000); for (String line : original.split("\n")) { if (line.startsWith("DTSTART")) { try { Date originalDate = sdf.parse(line.substring(8)); long now = System.currentTimeMillis(); if (originalDate.getTime() < now - day) { long newTime = now - day - (now % day) + (originalDate.getTime() % day); Date newDate = new Date(newTime); newCalendar.append("DTSTART:" + sdf.format(newDate) + "\n"); } else newCalendar.append("DTSTART:" + sdf.format(originalDate) + "\n"); } catch (ParseException e) { LogManager.getInstance(context).logException(e); return original; } } else if (line.startsWith("DTEND")) { try { Date originalDate = sdf.parse(line.substring(6)); long now = System.currentTimeMillis(); if (originalDate.getTime() < now - day) { long newTime = now - day - (now % day) + (originalDate.getTime() % day); Date newDate = new Date(newTime); newCalendar.append("DTEND:" + sdf.format(newDate) + "\n"); } else newCalendar.append("DTEND:" + sdf.format(originalDate) + "\n"); } catch (ParseException e) { LogManager.getInstance(context).logException(e); return original; } } else newCalendar.append(line + "\n"); } return newCalendar.toString(); } return original; } @Override public boolean updateFromMap(Context context, Map<String, Object> map) { if (super.updateFromMap(context, map)) { if (map.containsKey(DateTrigger.DATETIME_START)) this._start = map.get(DateTrigger.DATETIME_START).toString(); if (map.containsKey(DateTrigger.DATETIME_END)) this._end = map.get(DateTrigger.DATETIME_END).toString(); this._originalStart = this._start; this._originalEnd = this._end; SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss"); long now = System.currentTimeMillis(); try { if (this._end == null) this._end = this._start; Date startDate = sdf.parse(this._start); Date endDate = sdf.parse(this._end); long startTime = startDate.getTime(); long endTime = endDate.getTime(); while ((now - startTime) > (180 * 24 * 60 * 60 * 1000L)) { startTime += (24 * 60 * 60 * 1000); endTime += (24 * 60 * 60 * 1000); } while ((endTime - now) > (180 * 24 * 60 * 60 * 1000L)) { endTime -= (24 * 60 * 60 * 1000); } if (startTime > endTime) { long holder = startTime; startTime = endTime; endTime = holder; } this._start = sdf.format(new Date(startTime)); this._end = sdf.format(new Date(endTime)); } catch (ParseException ee) { LogManager.getInstance(context).logException(ee); } if (this._start != null && this._start.equals(this._end)) { try { Date start = sdf.parse(this._start); long time = start.getTime(); time += 60 * 1000; Date end = new Date(time); this._end = sdf.format(end); } catch (ParseException e) { LogManager.getInstance(context).logException(e); } } if (map.containsKey(DateTrigger.DATETIME_REPEATS)) this._repeats = map.get(DateTrigger.DATETIME_REPEATS).toString(); if (map.containsKey(DateTrigger.DATETIME_RANDOM)) this._random = (Boolean) map.get(DateTrigger.DATETIME_RANDOM); if (map.containsKey(DateTrigger.FIRE_ON_BOOT)) this._fireOnBoot = (Boolean) map.get(DateTrigger.FIRE_ON_BOOT); if ("null".equals(this._repeats)) this._repeats = null; String repeatString = ""; if (this._repeats != null) repeatString = "\nRRULE:" + this._repeats; this._icalString = String.format(context.getString(R.string.ical_template), this._start, this._end, this.name(), repeatString); this._lastFireCalcDate = 0; this.refresh(context); this.refreshCalendar(context); ManagerService.resetTriggerNudgeDate(); TriggerManager.getInstance(context).persistTriggers(context); return true; } return false; } @Override public Map<String, Object> configuration(Context context) { Map<String, Object> config = super.configuration(context); config.put(DateTrigger.DATETIME_START, this._start); config.put(DateTrigger.DATETIME_END, this._end); config.put(DateTrigger.DATETIME_REPEATS, this._repeats); config.put(DateTrigger.DATETIME_RANDOM, this._random); config.put(DateTrigger.FIRE_ON_BOOT, this._fireOnBoot); config.put("type", DateTrigger.TYPE_NAME); return config; } public DateTrigger(Context context, Map<String, Object> map) { super(context, map); this.updateFromMap(context, map); } public Period getPeriod(Context context, long timestamp) { Date date = new Date(timestamp); PeriodList periodList = null; if (this._calendar != null && periodList == null) { try { Date fromDate = new Date(timestamp - 5000); Date toDate = new Date(timestamp + (15 * 60 * 1000)); DateTime from = new DateTime(fromDate); DateTime to = new DateTime(toDate); Period period = new Period(from, to); for (Object o : this._calendar.getComponents("VEVENT")) { Component c = (Component) o; PeriodList l = c.calculateRecurrenceSet(period); if (l != null && l.size() > 0) periodList = l; } } catch (IllegalArgumentException e) { LogManager.getInstance(context).logException(e); } } try { for (Object po : periodList) { if (po instanceof Period) { Period p = (Period) po; DateRange range = new DateRange(p.getStart(), p.getEnd()); if (range.includes(date, DateRange.INCLUSIVE_START | DateRange.INCLUSIVE_END)) return p; } } } catch (NullPointerException e) { } return null; } @Override public void execute(Context context, boolean force) { long now = System.currentTimeMillis(); Period p = this.getPeriod(context, now); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); String key = "last_fired_" + this.identifier(); if (p != null && force == false) { long lastFired = prefs.getLong(key, 0); Date lastFireDate = new Date(lastFired); DateTime end = p.getEnd(); DateTime start = p.getStart(); DateRange range = new DateRange(start, end); if (range.includes(lastFireDate, DateRange.INCLUSIVE_START | DateRange.INCLUSIVE_END)) return; // Already fired. if (this._random && DateTrigger.random != null) { long timeLeft = System.currentTimeMillis(); long periodEnd = end.getTime(); long delta = periodEnd - timeLeft; delta = (delta / (60 * 1000)) - 1; // Normalize to minutes, drop // last minute if (delta > 1) { double fireThreshold = 1.0 / (double) delta; double randomDouble = random.nextDouble(); if (randomDouble > fireThreshold) return; // Not your time, please try again. } } } Editor edit = prefs.edit(); edit.putLong(key, now); edit.commit(); super.execute(context, force); } @Override public boolean matches(Context context, Object obj) { if (obj instanceof Date) { if (this._calendar == null) this.refreshCalendar(context); Date date = (Date) obj; Period p = this.getPeriod(context, date.getTime()); return (p != null); } return false; } @Override @SuppressWarnings("deprecation") public PreferenceScreen preferenceScreen(Context context, PreferenceManager manager) { PreferenceScreen screen = super.preferenceScreen(context, manager); SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss, yyyy-MM-dd"); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); Preference lastFire = new Preference(context); lastFire.setSummary(R.string.label_trigger_last_fire); String key = "last_fired_" + this.identifier(); long lastFireTime = prefs.getLong(key, 0); if (lastFireTime == 0) lastFire.setTitle(R.string.label_trigger_last_fire_never); else { Date d = new Date(lastFireTime); lastFire.setTitle(sdf.format(d)); } screen.addPreference(lastFire); synchronized (this._upcomingFireDates) { if (this._upcomingFireDates.size() > 0) { PreferenceScreen upcomingScreen = manager.createPreferenceScreen(context); upcomingScreen.setSummary(R.string.label_trigger_upcoming_fires); if (this._upcomingFireDates.size() == 1) upcomingScreen.setTitle(R.string.label_trigger_upcoming_fire_summary); else upcomingScreen.setTitle(String.format(context.getString(R.string.label_trigger_upcoming_fires_summary), this._upcomingFireDates.size())); for (Date d : this._upcomingFireDates) { Preference upcomingFire = new Preference(context); upcomingFire.setTitle(sdf.format(d)); upcomingScreen.addPreference(upcomingFire); } screen.addPreference(upcomingScreen); } else { Preference upcomingFires = new Preference(context); upcomingFires.setSummary(R.string.label_trigger_upcoming_fires); upcomingFires.setTitle(R.string.label_trigger_upcoming_fires_none); screen.addPreference(upcomingFires); } Preference originalStartString = new Preference(context); originalStartString.setSummary(R.string.label_trigger_original_start); originalStartString.setTitle(this._originalStart); screen.addPreference(originalStartString); Preference originalEndString = new Preference(context); originalEndString.setSummary(R.string.label_trigger_original_end); originalEndString.setTitle(this._originalEnd); screen.addPreference(originalEndString); Preference startString = new Preference(context); startString.setSummary(R.string.label_trigger_start); startString.setTitle(this._start); screen.addPreference(startString); Preference endString = new Preference(context); endString.setSummary(R.string.label_trigger_end); endString.setTitle(this._end); screen.addPreference(endString); Preference repeatString = new Preference(context); repeatString.setSummary(R.string.label_trigger_repeat); repeatString.setTitle(this._repeats); screen.addPreference(repeatString); Preference randomString = new Preference(context); randomString.setSummary(R.string.label_trigger_random); if (this._random) randomString.setTitle(R.string.label_trigger_is_random); else randomString.setTitle(R.string.label_trigger_not_random); screen.addPreference(randomString); Preference bootString = new Preference(context); bootString.setSummary(R.string.label_trigger_boot); if (this._fireOnBoot) bootString.setTitle(R.string.label_trigger_is_boot); else bootString.setTitle(R.string.label_trigger_not_boot); screen.addPreference(bootString); } return screen; } @Override public String getDiagnosticString(Context context) { String name = this.name(); String identifier = this.identifier(); long lastFired = this.lastFireTime(context); String lastFiredString = context.getString(R.string.trigger_fired_never); if (lastFired != 0) { DateFormat formatter = android.text.format.DateFormat.getMediumDateFormat(context); DateFormat timeFormatter = android.text.format.DateFormat.getTimeFormat(context); lastFiredString = formatter.format(new Date(lastFired)) + " " + timeFormatter.format(new Date(lastFired)); } String enabled = context.getString(R.string.trigger_disabled); if (this.enabled(context)) enabled = context.getString(R.string.trigger_enabled); return context.getString(R.string.trigger_diagnostic_string, name, identifier, enabled, lastFiredString); } @Override public Bundle bundle(Context context) { Bundle bundle = super.bundle(context); bundle.putBoolean(DateTrigger.RANDOM, this._random); bundle.putBoolean(DateTrigger.FIRE_ON_BOOT, this._fireOnBoot); bundle.putString(Trigger.TYPE, DateTrigger.TYPE_NAME); bundle.putString(DateTrigger.REPEATS, this._repeats); if (this._start != null) bundle.putString(DateTrigger.START, this._start); if (this._end != null) bundle.putString(DateTrigger.END, this._end); if (this._originalStart != null) bundle.putString(DateTrigger.ORIGINAL_START, this._originalStart); if (this._originalEnd != null) bundle.putString(DateTrigger.ORIGINAL_END, this._originalEnd); if (this._icalString != null) bundle.putString(DateTrigger.CALENDAR_STRING, this._icalString); return bundle; } public boolean missedFire(Context context, long end) { if (this._fireOnBoot) { this.refreshCalendar(context); long start = this.lastFireTime(context); try { DateTime from = new DateTime(new Date(start)); DateTime to = new DateTime(new Date(end)); Period period = new Period(from, to); for (Object o : this._calendar.getComponents("VEVENT")) { Component c = (Component) o; PeriodList l = c.calculateRecurrenceSet(period); if (l != null && l.size() > 0) return true; } } catch (IllegalArgumentException e) { LogManager.getInstance(context).logException(e); } } return false; } public List<Long> fireTimes(Context context, long start, long end) { ArrayList<Long> times = new ArrayList<>(); long offset = 0; while (start + offset <= end) { Period p = this.getPeriod(context, start + offset); if (p != null) { long fireTime = start + offset + 5000; times.add(fireTime); } offset += 60000; } return times; } public long lastMissedFireTime(Context context) { long now = System.currentTimeMillis(); long lastFired = this.lastFireTime(context); // Assumes that the device has been on and fired at least once in // the last two weeks. To go back indefinitely is computationally // infeasible. (See method above why.) if (lastFired == 0) lastFired = now - (14 * 24 * 60 * 60 * 1000); List<Long> missedFires = this.fireTimes(context, lastFired, now); if (missedFires.size() > 0) { Collections.sort(missedFires); Collections.reverse(missedFires); return missedFires.get(0); } return 0; } }